跳到主要内容

MySQL 的 MVCC 是什么?

如果是可重复读隔离级别,事务 T 启动的时候会创建一个视图 read-view,之后事务 T 执行期间,即使有其他事务修改了数据,事务 T 看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像与世无争,不受外界影响。

但是,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?

所以这就引入了一致性读的概念了,其中就包括了 MVCC(多版本并发控制)。

在讲 MVCC 之前,先来看看 MySQL 是如何实现一致性读的。

保证一致性读的两种方式

一致性锁定读(Consistent Locking Read)和一致性非锁定读(Consistent Non-locking Read)是两种读取数据的方式,用于保证读操作的一致性。

  1. 一致性锁定读:在进行读操作时,需要获取锁来确保数据的一致性。当一个线程或事务执行一致性锁定读时,其他线程或事务无法同时对数据进行修改。只有在锁被释放后,其他线程或事务才能修改数据。这种方式可以确保读取的数据是一致的,不会被并发的写操作所干扰。一致性锁定读保证了读取的数据是当前的最新值,但可能会引入锁竞争和性能开销。

  2. 一致性非锁定读:在进行读操作时,不需要获取锁,即使其他线程或事务正在对数据进行修改。一致性非锁定读不会阻塞写操作,因此读操作可以在并发写操作的情况下执行。然而,由于没有锁的保护,读操作可能读取到中间状态或过期的数据。这种方式适用于某些场景下读操作的实时性要求不高,可以容忍一定程度的数据不一致性。

选择一致性锁定读还是一致性非锁定读取决于应用程序的需求和数据的一致性要求。如果对于读取的数据必须保证最新和一致性,可以选择一致性锁定读。但这可能会带来锁竞争和性能开销。如果对于数据的实时性要求不高,可以选择一致性非锁定读,以获得更好的并发性能。

需要根据具体的应用场景和业务需求来选择适当的读取方式,并综合考虑数据一致性、并发性能和系统开销等因素。

一致性锁定读(手动加锁)

在某些情况下,用户需要 显式地对数据库读取操作进行加锁 以保证数据逻辑的一致性。而这要求数据库支持加锁语句,即使是对于 SELECT 的只读操作。

InnoDB 存储引擎对于 SELECT 语句支持两种一致性的锁定读(locking read)操作:

  • SELECT...FOR UPDATE : 对读取的行记录加一个 X 锁
  • SELECT...LOCK IN SHARE MODE : 对读取的行记录加一 个 S 锁
select * from table where id = ? lock in share mode
select * from table where id < ? lock in share mode
select * from table where id = ? for update
select * from table where id < ? for update

SELECT...LOCK IN SHARE MODE 对读取的行记录加一 个 S 锁,其他事务可以向被锁定的行加 S 锁,但是如果加 X 锁,则会被阻塞。

SELECT...FOR UPDATE 对读取的行记录加一个 X 锁,其他事务不能对已锁定的行加上任何锁。

备注

在MySQL中,X锁(Exclusive Lock)和S锁(Shared Lock)是用于控制并发访问的锁机制。

  1. X锁(Exclusive Lock):也称为写锁,当事务需要修改某个资源(如表、行、页等)时,会获取X锁。X锁是互斥的,即同一时间只能有一个事务持有X锁,其他事务无法同时持有X锁或S锁。当一个事务持有X锁时,其他事务无法对同一资源进行读取或写入操作,直到持有X锁的事务释放锁。

  2. S锁(Shared Lock):也称为读锁,当事务需要读取某个资源时,会获取S锁。S锁是共享的,即多个事务可以同时持有S锁,但无法与持有X锁的事务并行。多个事务持有S锁时可以同时读取相同的资源,但不能进行修改操作。只有当没有事务持有X锁时,其他事务才能获取X锁。

X锁和S锁的使用方式可以简化为以下规则:

  • X锁之间互斥,与S锁互斥,保证了写操作的独占性。
  • S锁与S锁之间不互斥,多个事务可以同时持有S锁,实现了并发读取。

这种锁机制确保了事务之间的隔离性和一致性。读操作(获取S锁)可以并发进行,但读操作与写操作(获取X锁)之间是互斥的,以防止读取到不一致的数据。

需要注意的是,在默认的隔离级别(REPEATABLE READ)下,MySQL会在读取数据时自动添加S锁,而在进行写操作时自动添加X锁。事务在需要读取或修改数据时会自动获取适当的锁。但在一些特殊情况下,可能需要手动控制锁的获取和释放,以确保并发操作的正确性和效率。

对于一致性非锁定读,即使读取的行已被执行了 SELECT...FOR UPDATE,也是可以进行读取的,这和之前讨论的情况一样。此外,SELECT...FOR UPDATESELECT...LOCK IN SHARE MODE 必须在一个事务中,当事务提交了,锁也就释放了。

因此在使用,上述两句 SELECT 锁定语句时,务必加上 BEGINSTART TRANSACTION 或者 SET AUTOCOMMIT=0

一致性非锁定读(MVCC)

一致性的非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(MVCC)的方式来读取当前执行时间数据库中行的数据。

如果读取的行,正在执行 DELETE 或 UPDATE 操作,这时读取操作不会因此去等待行上锁的释放。

相反地,InnoDB 存储引擎会去读取行的一个快照数据。如图6-4所示。

图 6-4 直观地展现了 InnoDB 存储引擎一致性的非锁定读。之所以称其为非锁定读,因为不需要等待访问的行上 X 锁的释放。

快照数据是指该行的之前版本的数据,该实现是通过 undo 段来完成。而 undo 用来在事务中回滚数据,因此快照数据本身是没有额外的开销。(下面的几节会讲具体的原理)

此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。

可以看到,非锁定读机制极大地提高了数据库的并发性。在 InnoDB 存储引擎的默认设置下,这是默认的读取方式,即读取不会占用和等待表上的锁。但是在不同事务隔离级别下,读取的方式不同,并不是在每个事务隔离级别下都是采用非锁定的一致性读。

此外,即使都是使用非锁定的一致性读,但是对于快照数据的定义也各不相同。

通过图 6-4 可以知道,快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本。就图 6-4 所显示的,一个行记录可能有不止一个快照数据,一般称这种技术为行多版本技术。

由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control,MVCC)。

然而在不同的隔离级别下,对于快照数据的定义却不相同。

MVCC 在不同隔离级别下的表现

在事务隔离级别 READ COMMITTED 和 REPEATABLE READ(InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用非锁定的一致性读。

上面说到 在不同的隔离级别下,对于快照数据的定义却不相同。

  • 在 READ COMMITTED (读提交)事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据;
  • 而在 REPEATABLEREAD (可重复读)事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。

来看下面的一个例子,首先在当前 MySQL 数据库的连接会话 A 中执行如下 SQL 语句:

-- SessionA 
Begin;
select * from parent where id = 1;

会话 A 中已通过显式地执行命令 BEGIN 开启了一个事务,并读取了表 parent 中 id 为 1 的数据,但是事务并没有结束。与此同时,用户再开启另一个会话 B,这样可以模拟并发的情况,然后对会话 B 做如下的操作:

-- SessionB 
Begin;
update parent set id = 3 where id = 1;

在会话 B 中将事务表 parent 中 id 为 1 的记录修改为 id = 3,但是事务同样没有提交,这样 id = 1 的行其实加了一个 X 锁。

这时如果在会话 A 中再次读取 id 为 1 的记录,根据 InnoDB 存储引擎的特性,即在 READ COMMITTED 和 REPEATABLE READ 的事务隔离级别下会使用非锁定的一致性读。

回到之前的会话 A,接着上次未提交的事务,执行 SQL 语句 SELECT * FROM parent WHERE id = 1 的操作,这时不管使用 READ COMMITTED 还是 REPEATABLE READ 的事务隔离级别,显示的数据应该都是 1(此时 B 还未提交)

由于当前 id = 1 的数据被修改了 1 次,因此只有一个行版本的记录。接着,在会话 B 中提交上次的事务:

在会话 B 提交事务后,这时在会话 A 中再运行 SELECT * FROM parent WHERE id = 1 的 SQL 语句,在 READ COMMITTED 和 REPEATABLE READ 事务隔离级别下得到结果就不一样了。

首先检查一下当前的隔离级别(默认就是 REPEATABLE READ):

-- 查看当前会话隔离级别
select @@tx_isolation;

-- 补充:
-- 查看系统当前隔离级别
select @@global.tx_isolation;

-- 设置当前会话隔离级别
set session transaction isolation level repeatable read;

-- 设置系统当前隔离级别
set global transaction isolation level repeatable read;

下面分别看下在不同的隔离级别下,对于快照数据的定义:

REPEATABLE READ 可重复读

对于 REPEATABLE READ 的事务隔离级别,总是读取事务开始时的行数据。因此对于 REPEATABLE READ 事务隔离级别,其得到的结果如下:

先提交了上面的事务,把数据初始化好后,设置事务级别为 READ COMMITTED 重新测试一次

-- Session A
set session transaction ISOLATION level READ COMMITTED;
Begin;
select @@tx_isolation; -- READ-COMMITTED
select * from parent where id = 1;

READ COMMITTED 读提交

而对于 READ COMMITTED 的事务隔离级别,它总是读取行的最新版本,如果行被锁定了,则 读取该行版本的最新一个快照(fresh snapshot)。 在上述例子中,因为会话 B 已经提交了事务,所以 READ COMMITTED 事务隔离级别下会得到如下结果:

下面将从时间的角度展现上述演示的示例过程,如表 6-8 所示。需要特别注意的是,对于 READ COMMITTED 的事务隔离级别而言,从数据库理论的角度来看,其违反了事务 ACID 中的 I 的特性,即隔离性。

MVCC 的实现原理

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

这时,你会说这看上去不太现实啊。如果一个库有100G,那么我启动一个事务,MySQL就要拷贝100G的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。

实际上,我们并不需要拷贝出这100G的数据。我们先来看看这个快照是怎么实现的。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB的 事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的 row trx_id。如图所示,就是一个记录被多个事务连续更新后的状态

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

你可能会问,前面的文章不是说,语句更新会生成 undo log(回滚日志)吗?那么,undo log 在哪呢?

实际上,图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义那个 “100G” 的快照的。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃” 指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。这个视图数组把所有的 row trx_id 分成了几种不同的情况。

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况
  • 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
  • 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于下图中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。

有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。

所以你现在知道了,InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

Reference